| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- ---
- import Layout from "@layouts/layout.astro";
- import Nav from "@components/Nav.astro";
- import KlaskBoard from "@components/KlaskBoard.astro";
- import VirtualGame from "@components/VirtualGame.astro";
- import { init, getGame, getUser } from "@utils/db";
- import { getUserFromRequest } from "@utils/auth";
- await init();
- const { id } = Astro.params;
- if (!id) return Astro.redirect("/");
- const game = await getGame(id);
- if (!game) {
- return new Response("Game not found", { status: 404 });
- }
- const currentUserName = getUserFromRequest(Astro.request);
- const currentUser = currentUserName ? await getUser(currentUserName) : null;
- const isPlayer1 = currentUserName === game.player1;
- const isPlayer2 = currentUserName === game.player2;
- const isParticipant = isPlayer1 || isPlayer2;
- const canJoin = !isParticipant && game.status === "waiting" && currentUserName && currentUserName !== game.player1;
- const origin = new URL(Astro.request.url).origin;
- const gameUrl = `${origin}/game/${id}`;
- const isVirtual = game.mode === "virtual";
- ---
- <Layout
- title={`${game.player1} vs ${game.player2 ?? "?"}`}
- currentUser={currentUserName}
- currentRating={currentUser?.rating ?? null}
- >
- <Nav currentUser={currentUserName} currentRating={currentUser?.rating ?? null} />
- <main class="container game-page">
- <div class="mode-badge-row">
- <span class:list={["mode-badge", isVirtual ? "mode-virtual" : "mode-inperson"]}>
- {isVirtual ? "🌐 Virtual" : "🏓 In Person"}
- </span>
- </div>
- {/* --- WAITING STATE (both modes) --- */}
- {game.status === "waiting" && (
- <div class="waiting-layout">
- {!isVirtual && (
- <div class="board-side">
- <KlaskBoard size="lg" />
- </div>
- )}
- <div class="score-side">
- <div class="waiting-panel card">
- <p class="section-label">Waiting for opponent</p>
- <h2 class="waiting-title">Share this game</h2>
- <div class="link-row" style="margin-bottom:16px">
- <input type="text" id="share-link" value={gameUrl} readonly />
- <button id="copy-link-btn" class="btn btn-secondary" style="width:auto; padding:10px 14px; flex-shrink:0">Copy</button>
- </div>
- <div class="qr-wrap">
- <img src={`/api/qr/${id}`} alt="QR Code" class="qr-img" />
- </div>
- {canJoin && (
- <button id="join-btn" class="btn" style="width:100%; padding:12px; margin-top:16px">
- Join as {currentUserName}
- </button>
- )}
- {!currentUserName && (
- <a href={`/login?redirect=/game/${id}`} class="btn" style="width:100%; padding:12px; margin-top:16px; display:block; text-align:center">
- Sign in to join
- </a>
- )}
- <p class="muted" style="font-size:12px; margin-top:12px; text-align:center">
- Created by <strong>{game.player1}</strong>
- </p>
- </div>
- </div>
- </div>
- )}
- {/* --- VIRTUAL ACTIVE GAME --- */}
- {isVirtual && game.status === "active" && game.player2 && (
- <VirtualGame
- gameId={id}
- isHost={isPlayer1}
- player1={game.player1}
- player2={game.player2}
- currentUser={currentUserName ?? ""}
- />
- )}
- {/* --- IN-PERSON ACTIVE GAME --- */}
- {!isVirtual && game.status !== "waiting" && game.status !== "complete" && (
- <div class="game-layout">
- <div class="board-side">
- <KlaskBoard size="lg" />
- </div>
- <div class="score-side">
- <div class="scoreboard">
- <div class="player-row player-top" id="p2-row">
- <div class="player-name-wrap">
- <span class="player-label-badge p2-badge">●</span>
- <a href={`/profile/${encodeURIComponent(game.player2 ?? "")}`} class="player-link">
- {game.player2 ?? "Player 2"}
- </a>
- {isPlayer2 && <span class="you-tag">you</span>}
- </div>
- <button class="score-btn score-btn-p2" id="score-p2" data-player="2" disabled={!isParticipant || undefined}>
- <span class="score-num" id="score2">{game.score2}</span>
- <span class="score-plus">+1</span>
- </button>
- </div>
- <div class="progress-bars">
- <div class="progress-bar"><div class="progress-fill p1-fill" id="p1-fill" style={`width: ${(game.score1 / 6) * 100}%`}></div></div>
- <div class="progress-divider"><span class="progress-label">First to 6</span></div>
- <div class="progress-bar"><div class="progress-fill p2-fill" id="p2-fill" style={`width: ${(game.score2 / 6) * 100}%`}></div></div>
- </div>
- <div class="player-row player-bottom" id="p1-row">
- <div class="player-name-wrap">
- <span class="player-label-badge p1-badge">●</span>
- <a href={`/profile/${encodeURIComponent(game.player1)}`} class="player-link">
- {game.player1}
- </a>
- {isPlayer1 && <span class="you-tag">you</span>}
- </div>
- <button class="score-btn score-btn-p1" id="score-p1" data-player="1" disabled={!isParticipant || undefined}>
- <span class="score-num" id="score1">{game.score1}</span>
- <span class="score-plus">+1</span>
- </button>
- </div>
- {isParticipant && (
- <div class="game-actions">
- <button id="end-game-btn" class="btn btn-danger" style="width:100%; padding:11px">
- End & Submit Result
- </button>
- <p class="muted" style="font-size:11px; text-align:center; margin-top:6px">
- Ends the game and saves ratings
- </p>
- </div>
- )}
- {!isParticipant && (
- <p class="muted" style="font-size:12px; text-align:center; margin-top:16px">Spectating</p>
- )}
- </div>
- </div>
- </div>
- )}
- {/* --- COMPLETE (both modes) --- */}
- {game.status === "complete" && (
- <div class="result-center">
- <div class="result-panel card">
- <p class="section-label">Game Over</p>
- <h2 class="result-winner">
- {game.winner} <span class="green">wins!</span>
- </h2>
- <div class="final-score">
- <span>{game.player1}</span>
- <span class="final-score-num">{game.score1} — {game.score2}</span>
- <span>{game.player2}</span>
- </div>
- <div id="rating-changes" class="rating-changes"></div>
- <div style="display:flex; gap:8px; margin-top:20px">
- <a href="/play" class="btn" style="flex:1; text-align:center; padding:11px">New Game</a>
- <a href="/" class="btn btn-secondary" style="flex:1; text-align:center; padding:11px">Home</a>
- </div>
- </div>
- </div>
- )}
- </main>
- </Layout>
- <script define:vars={{ gameId: id, initialStatus: game.status, isParticipant, gameUrl, isVirtual }}>
- // Only run polling/button logic for in-person games
- if (!isVirtual) {
- let pollInterval = null;
- async function pollGame() {
- const res = await fetch(`/api/game/${gameId}`);
- if (!res.ok) return;
- const game = await res.json();
- const s1 = document.getElementById("score1");
- const s2 = document.getElementById("score2");
- if (s1) s1.textContent = game.score1;
- if (s2) s2.textContent = game.score2;
- const p1Fill = document.getElementById("p1-fill");
- const p2Fill = document.getElementById("p2-fill");
- if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
- if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
- if (game.status === "active" && initialStatus === "waiting") location.reload();
- if (game.status === "complete" && initialStatus !== "complete") location.reload();
- }
- document.querySelectorAll(".score-btn").forEach(btn => {
- btn.addEventListener("click", async () => {
- const player = btn.dataset.player;
- btn.disabled = true;
- const res = await fetch(`/api/game/${gameId}/score`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ player: parseInt(player) }),
- });
- btn.disabled = false;
- if (res.ok) {
- const game = await res.json();
- const s1 = document.getElementById("score1");
- const s2 = document.getElementById("score2");
- if (s1) s1.textContent = game.score1;
- if (s2) s2.textContent = game.score2;
- const p1Fill = document.getElementById("p1-fill");
- const p2Fill = document.getElementById("p2-fill");
- if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
- if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
- }
- });
- });
- const endBtn = document.getElementById("end-game-btn");
- if (endBtn) {
- endBtn.addEventListener("click", async () => {
- if (!confirm("End the game and save results?")) return;
- endBtn.disabled = true;
- endBtn.textContent = "Saving...";
- const res = await fetch(`/api/game/${gameId}/complete`, { method: "POST" });
- if (res.ok) location.reload();
- else { endBtn.disabled = false; endBtn.textContent = "End & Submit Result"; }
- });
- }
- if (initialStatus === "waiting" || initialStatus === "active") {
- pollInterval = setInterval(pollGame, 3000);
- }
- }
- // Shared: waiting state buttons
- const copyLinkBtn = document.getElementById("copy-link-btn");
- if (copyLinkBtn) {
- copyLinkBtn.addEventListener("click", () => {
- navigator.clipboard.writeText(gameUrl);
- copyLinkBtn.textContent = "Copied!";
- setTimeout(() => { copyLinkBtn.textContent = "Copy"; }, 2000);
- });
- }
- const joinBtn = document.getElementById("join-btn");
- if (joinBtn) {
- joinBtn.addEventListener("click", async () => {
- joinBtn.disabled = true;
- const res = await fetch(`/api/game/${gameId}`, { method: "POST" });
- if (res.ok) location.reload();
- else { joinBtn.disabled = false; alert("Failed to join game."); }
- });
- }
- // For virtual waiting, also poll for opponent join
- if (isVirtual && initialStatus === "waiting") {
- setInterval(async () => {
- const res = await fetch(`/api/game/${gameId}`);
- if (!res.ok) return;
- const game = await res.json();
- if (game.status === "active") location.reload();
- }, 2000);
- }
- </script>
- <style>
- .game-page { padding: 24px 16px 48px; }
- .mode-badge-row { margin-bottom: 16px; }
- .mode-badge {
- display: inline-block;
- font-size: 11px;
- letter-spacing: 0.06em;
- padding: 4px 10px;
- border-radius: 3px;
- text-transform: uppercase;
- }
- .mode-inperson { background: rgba(129,182,76,0.12); color: var(--green); border: 1px solid rgba(129,182,76,0.25); }
- .mode-virtual { background: rgba(90,140,220,0.12); color: #7aabee; border: 1px solid rgba(90,140,220,0.25); }
- .waiting-layout, .game-layout {
- display: flex;
- gap: 40px;
- align-items: flex-start;
- justify-content: center;
- }
- .board-side { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; }
- .score-side { flex: 0 0 320px; }
- .waiting-panel { text-align: center; }
- .waiting-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.6rem; letter-spacing: 0.05em; margin-bottom: 16px; }
- .link-row { display: flex; gap: 8px; }
- .link-row input { font-size: 12px; }
- .qr-wrap { display: flex; justify-content: center; margin: 0 auto; }
- .qr-img { width: 160px; height: 160px; background: white; padding: 10px; border-radius: 4px; display: block; }
- .scoreboard { display: flex; flex-direction: column; gap: 16px; }
- .player-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
- .player-name-wrap { display: flex; align-items: center; gap: 8px; flex: 1; }
- .player-label-badge { font-size: 10px; }
- .p1-badge { color: #5c8bd6; }
- .p2-badge { color: #d46060; }
- .player-link { color: var(--text); text-decoration: none; font-size: 14px; font-weight: 500; }
- .player-link:hover { color: var(--green-light); }
- .you-tag { font-size: 10px; color: var(--green); background: rgba(129,182,76,0.15); border: 1px solid rgba(129,182,76,0.3); border-radius: 3px; padding: 1px 5px; }
- .score-btn { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: 'DM Mono', monospace; cursor: pointer; display: flex; flex-direction: column; align-items: center; padding: 8px 16px; gap: 2px; transition: background 0.1s, transform 0.08s; min-width: 72px; }
- .score-btn:hover:not(:disabled) { background: var(--card-hover); }
- .score-btn:active:not(:disabled) { transform: scale(0.96); }
- .score-btn:disabled { cursor: default; opacity: 0.7; }
- .score-btn-p1:hover:not(:disabled) { border-color: #5c8bd6; }
- .score-btn-p2:hover:not(:disabled) { border-color: #d46060; }
- .score-num { font-family: 'Bebas Neue', sans-serif; font-size: 3.5rem; line-height: 1; letter-spacing: 0.02em; }
- .score-plus { font-size: 10px; color: var(--text-muted); letter-spacing: 0.05em; }
- .score-btn:disabled .score-plus { display: none; }
- .progress-bars { display: flex; flex-direction: column; gap: 6px; }
- .progress-bar { height: 6px; background: var(--bg-darker); border-radius: 3px; overflow: hidden; }
- .progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease; }
- .p1-fill { background: #5c8bd6; }
- .p2-fill { background: #d46060; }
- .progress-divider { text-align: center; }
- .progress-label { font-size: 10px; color: var(--text-dim); letter-spacing: 0.08em; text-transform: uppercase; }
- .result-center { display: flex; justify-content: center; }
- .result-panel { text-align: center; max-width: 400px; width: 100%; }
- .result-winner { font-family: 'Bebas Neue', sans-serif; font-size: 2.2rem; letter-spacing: 0.05em; margin-bottom: 16px; }
- .final-score { display: flex; align-items: center; justify-content: center; gap: 16px; color: var(--text-muted); font-size: 13px; margin-bottom: 8px; }
- .final-score-num { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; color: var(--text); }
- .rating-changes { font-size: 13px; color: var(--text-muted); margin-top: 8px; }
- .game-actions { margin-top: 8px; }
- @media (max-width: 800px) {
- .game-layout, .waiting-layout { flex-direction: column; align-items: center; }
- .score-side { flex: 0 0 auto; width: 100%; max-width: 480px; }
- }
- </style>
|